Skip to content

feat(tilemap): Uint16Array layerData + drawTileRaw renderer bypass (#1401 foundation)#1445

Merged
obiot merged 5 commits into
masterfrom
feat/gpu-tilemap-1401
May 12, 2026
Merged

feat(tilemap): Uint16Array layerData + drawTileRaw renderer bypass (#1401 foundation)#1445
obiot merged 5 commits into
masterfrom
feat/gpu-tilemap-1401

Conversation

@obiot
Copy link
Copy Markdown
Member

@obiot obiot commented May 11, 2026

Summary

First commit toward #1401 — GPU-accelerated tilemap rendering. Refactors the TMX tile-layer data layout and renderer hot path so the upcoming WebGL2 shader path has a contiguous, GPU-uploadable backing store to draw from. No GPU shader code yet — that lands on top of this foundation in a follow-up PR.

What changed

  • TMXLayer.layerData is now a flat Uint16Array (GID + 3-bit flip mask per cell, row-major). Stable user-facing Tile identity is preserved via a lazy cachedTile view-cache that allocates on first cellAt/getTile call — games that never query tiles by coord keep it null for the layer's lifetime.
  • Map parsing decodes TMX layer data straight into bytes — zero Tile allocations during load.
  • Orientation renderers (Orthogonal / Oblique / Isometric / Hexagonal) read layerData directly and dispatch via new drawTileRaw methods on each renderer and on TMXTileset. The per-frame render loop never constructs a Tile.
  • TMXLayer.dataVersion counter bumps on setTile/clearTile — groundwork for GPU upload invalidation in the shader path.
  • buildFlipTransform(matrix, flipMask, w, h) helper shared between Tile.setTileTransform and the new raw render path.

Public API

UnchangedgetTile, setTile, cellAt, clearTile, getTileId, getTileById, getRenderable all work as before.

Wins

  • Memory: per-layer drops ~25× for games that don't query tiles by coord (~40 KB vs ~1 MB on a 100×100 dense layer).
  • Map load: ~500 000 Tile constructor calls saved on a dense 1000×1000 map.
  • Per-frame: zero Tile allocations during render. Modest FPS gain on Canvas (~2–5% in tile-heavy scenes); WebGL gains come with the shader path.

Tests

  • tmxlayer-data.spec.js (68 adversarial encoding tests) — all 8 flip combinations, GID range edge cases, identity stability via lazy cache, bounds validation, dataVersion monotonicity, parser-path no-allocation guard, cross-cell isolation, real-fixture round-trip.
  • tmxlayer-drawraw.spec.js (16 parity tests) — pixel-level byte-for-byte parity between legacy drawTile and new drawTileRaw for every flip combination; spy-verified zero Tile construction during render; buildFlipTransform matches Tile.setTileTransform.
  • Full suite: 3021/3021 passing.
  • CodeRabbit: 0 findings on uncommitted review (final pass).
  • Visual: platformer + isometric-rpg examples render identically to master.

Next

Phase 2 of #1401: WebGLRenderer.drawTileLayer shader path on top of this foundation — single quad per tileset, fragment-shader tile lookup via RG16UI index texture upload.

Test plan

  • Build clean (pnpm -F melonjs build)
  • Vitest suite green
  • CodeRabbit clean
  • Platformer renders identically
  • Isometric-rpg renders identically
  • CI green

🤖 Generated with Claude Code

…1401)

Foundation for the upcoming GPU tilemap shader path. Refactors TMX tile
layer storage and rendering so that:

- TMXLayer.layerData is now a flat Uint16Array (GID + flip mask per cell).
  Stable Tile identity is preserved via a lazy cachedTile view-cache that
  allocates on the first user-facing cellAt/getTile call — games that never
  query tiles by coord keep it null for the layer's lifetime.
- Map parsing decodes TMX layer data straight into bytes — zero Tile
  allocations during parse (saves ~500k constructor calls on a dense
  1000x1000 map).
- The orientation renderers (Orthogonal / Oblique / Isometric / Hexagonal)
  read layerData directly and dispatch via new drawTileRaw methods on each
  renderer and on TMXTileset. The per-frame render loop never constructs a
  Tile, never touches cachedTile.
- TMXLayer.dataVersion counter bumps on setTile/clearTile — groundwork for
  GPU upload invalidation in the shader path.
- New buildFlipTransform(matrix, flipMask, w, h) helper shared between
  Tile.setTileTransform and the new raw render path.
- Public API (getTile, setTile, cellAt, clearTile, getTileId, getTileById,
  getRenderable) is unchanged.

Memory: per-layer drops ~25x for games that don't query tiles by coord
(~40 KB vs ~1 MB on a 100x100 dense layer). Per-frame FPS: modest gain on
Canvas (~2-5% in tile-heavy scenes); the big WebGL win lands when the
shader path comes online on top of this foundation.

Tests: tmxlayer-data.spec.js (68 adversarial encoding tests including all
8 flip combinations + real-fixture snapshot) + tmxlayer-drawraw.spec.js
(16 parity tests between legacy drawTile and new drawTileRaw). 3021/3021
suite green.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copilot AI review requested due to automatic review settings May 11, 2026 09:59
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

…ps (#1401)

Visible tile layers now render as a single quad whose fragment shader
walks the per-layer GID index texture and samples the tileset atlas.
Supports animated tiles, flip bits (H/V/AD), per-layer opacity/tint/
blend mode, and oversized bottom-aligned tiles. Falls back to the
legacy CPU renderer on isometric/staggered/hexagonal layers,
collection-of-image tilesets, non-zero tileoffset, or non-WebGL-2
contexts. Application emits a one-shot warning when `gpuTilemap` is
enabled but the renderer can't honor it.

Supporting changes:

- WebGL: custom GLSL ES 3.00 shaders supported through `GLShader`
  (precision injector + attribute extractor handle both 1.00 and
  3.00). `ShaderEffect` is still 1.00-only since it pairs the user's
  fragment with the built-in 1.00 quad vertex shader.
- New `TextureResource` / `BufferTextureResource` for synthesized
  (raw-buffer) textures flowing through the standard `TextureCache`
  + batcher path. Used by the GPU TMX renderer for the per-layer
  index texture and per-tileset animation lookup.
- Uniform value caching across every shader the engine builds —
  redundant `gl.uniform*` calls are now skipped. Vec/mat values
  compare element-wise so scratch-buffer mutation is detected.
- TMX fragment-shader fast path on `uOverflow == (0, 0)` (the common
  case) skips the worst-case 25-iteration candidate-cell loop.

Fixes & related cleanups:

- `MaterialBatcher.uploadTexture` was using its `w`/`h` parameters
  (destination quad size) for the POT check, causing
  `gl.generateMipmap` to fire on NPOT atlases under WebGL 1
  (`GL_INVALID_OPERATION`) and silent wasted work under WebGL 2.
  Texture dimensions are now derived from the source.
- Removed the unconditional `[Texture] ... is not a POT texture`
  warning; replaced with a scoped warning when `repeat: "repeat*"`
  is requested on an NPOT texture under WebGL 1 (the one case
  where intent is silently downgraded).
- `setPrecision` correctly skips injection when the user already
  declares precision after `#version`, and handles single-line
  shaders without a trailing newline.
- Attribute extractor accepts precision qualifiers between
  `attribute`/`in` and the type (e.g. `attribute highp vec3 foo`).
- `Renderer.drawTileLayer` cached-canvas blit clamps the source
  rect to `layer.width - rect.pos.x` so a scrolled camera doesn't
  read past the canvas.
- `BufferTextureResource.upload` throws a clear error when format
  `"rgba8ui"` is used on a WebGL 1 context.

Rough perf win on a mid-tier mobile GPU (3-layer 800×600 game):
~1.5–3.5 ms reclaimed per frame; dense large maps should see ~5–8×
on the rendering portion. New tests cover flip math, uniform
caching, texture-resource lifecycle, and shader-path eligibility.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 31 out of 31 changed files in this pull request and generated 5 comments.

Comment thread packages/melonjs/src/level/tiled/TMXLayer.js
Comment thread packages/melonjs/src/level/tiled/TMXLayer.js
Comment thread packages/melonjs/src/level/tiled/TMXLayer.js
Comment thread packages/melonjs/src/video/webgl/renderers/tmxlayer/orthogonal.js
Comment thread packages/melonjs/src/physics/world.js Outdated
obiot and others added 2 commits May 12, 2026 13:40
CodeQL flagged the new GLSL 3.00-aware attribute regex as a
polynomial-time ReDoS risk: `\s` matches newlines, and the
`(?:\w+\s+)+` repetition combined with `(?:^|\n)\s*` made the
engine backtrack quadratically when fed shader sources with many
consecutive blank lines.

Switch all interior whitespace classes to `[ \t]+` (horizontal
only). Declarations live on one line in practice (the engine's
own minifier removes redundant newlines anyway), and the `(?:^|\n)`
line anchor remains, so behavior is unchanged on every real shader
in the engine and the regex is now ambiguity-free.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- `cellAt(x, y, false)`: out-of-range coord reads return `undefined`
  from the typed array, which fell through the `gid === 0` check and
  tried to resolve an invalid GID. Use `!gid` so both empty cells
  and out-of-range reads short-circuit to `null`.
- `setLayerData`: emit a one-shot `console.warn` when an incoming
  TMX layer contains a (flip-stripped) GID greater than 0xFFFF —
  the `Uint16Array` would otherwise silently truncate it and render
  the wrong tile.
- `setTile`: same one-shot warning for runtime mutations with an
  oversized tile id.
- `orthogonal.js` constructor comment: stop claiming the shader uses
  `usampler2D` + integer reads; document that we use `sampler2D` +
  `texelFetch` + float decode (the actual current behavior, chosen
  to avoid clashing with the engine's multi-texture batching cache).
- `World.gpuTilemap` doc: expand the feature list to match the
  shader path's real capabilities (animations, flip bits, opacity/
  tint/blend, bottom-aligned overflow up to 4 cells) and tighten
  the fallback list (non-zero `tileoffset`, overflow > 4 cells).

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Copilot AI review requested due to automatic review settings May 12, 2026 06:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 31 out of 31 changed files in this pull request and generated 3 comments.

Comment thread packages/melonjs/src/video/webgl/batchers/material_batcher.js Outdated
Comment thread packages/melonjs/src/video/webgl/webgl_renderer.js Outdated
Comment thread packages/melonjs/src/video/webgl/renderers/tmxlayer/orthogonal.js Outdated
- `MaterialBatcher.uploadTexture`: prefer `source.videoWidth` /
  `source.videoHeight` as a fallback when `width`/`height` are 0.
  `HTMLVideoElement` exposes its actual pixel dimensions through
  the video-specific properties, so the POT/repeat logic was
  feeding `0` into `isPowerOfTwo` for unsized video sources.
- `WebGLRenderer.reset()`: only drop `_orthogonalTMXGPURenderer`
  when the GL context is no longer valid. On a regular `GAME_RESET`
  (level transition with the context still live) we want to keep
  the cached `GLShader` so its compiled `WebGLProgram` survives,
  rather than leaking it and re-paying the link cost every reset.
  Per-layer textures are still freed unconditionally via the
  renderer's own `reset()`.
- `OrthogonalTMXLayerGPURenderer.reset()`: when no batcher is
  active (context tear-down), the fallback path called
  `cache.delete(resource)`, but that only clears the image→atlas
  map and leaves the texture-unit assignment in `cache.units`.
  Call `cache.freeTextureUnit(resource)` alongside it so unit
  slots aren't held forever.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
@obiot obiot merged commit 690edcd into master May 12, 2026
6 checks passed
@obiot obiot deleted the feat/gpu-tilemap-1401 branch May 12, 2026 06:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants